La deserción de empleados en las empresas (employee attrition) es un aspecto inevitable debido a variadas razones. Por ejemplo, el retiro puede obedecer a una actualización de conocimientos (estudios) que abre otras oportunidades para el trabajador; a razones familiares que impliquen mudarse lejos del lugar de trabajo; imperativos de salud y el haber cumplido con los requisitos para optar a la jubilación. Es decir, la disminución de una plantilla de empleados no obedece necesariamente a que aquellos tengan problemas con su empleador, sino que puede responder a factores relacionados con las condiciones y desarrollos de vida de cada uno. Sin embargo, para las empresas sería de gran valor identificar con suficiente antelación los empleados que podrían considerar el abandono de sus puestos de trabajo y los factores relacionados con estas decisiones.
Utilizar un algoritmo de basado en combinación de clasificadores (Ensembles) para la estimación de este modelo de predicción. Identificar las variables más importantes para el problema. Nota: utilice el conjunto de datos preparado en del Taller 1.
se localizan en la carpeta local "/data" para facil lectura en otros computadores.
# importando dependencias de trabajo
# facilitar el trabajo en Jupyter
# from IPython.core.interactiveshell import InteractiveShell
# InteractiveShell.ast_node_interactivity = "all"
# se importa OS, pandas y numpy para leer y preparar datos
import os
from collections import OrderedDict
from collections import Counter
import pandas as pd
from pandas import ExcelWriter
from pandas import ExcelFile
import pandas_profiling as profile
import numpy as np
# los modelos de aprendizaje son
from sklearn.ensemble import RandomForestClassifier
# sklearn para manejo de experimentos, pruebas, optimizacion y otros
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix
from sklearn.metrics import classification_report
from sklearn.model_selection import cross_val_score
from sklearn.metrics import accuracy_score
from sklearn.metrics import precision_score
from sklearn.metrics import make_scorer
from sklearn.model_selection import GridSearchCV
from imblearn.over_sampling import SMOTE
Se carga y se prueba disponibilidad del archivo "IBM-Employee-Attrition.csv".
# se cargan por medio de un path abstracto
# definiendo los nombres del archivo de datos para entrenamiento
# archivo de entrenamiento
sourceFile = os.path.join("data", "IBM-Employee-Attrition.csv")
sourceData = pd.read_csv(os.path.join(os.getcwd(), sourceFile), sep = ',', engine = 'python')
# probando que el archivo de entrenamiento carga
sourceData.head()
Se realiza un análisis preliminar con pandas_profile. Luego, se preparan los nombres de las columnas del conjunto de dato y se modifican para evitar confusiones en el procesamiento.
Se revisa si se necesita hacer alguna conversión del tipo de las columnas para mejorar el tiempo de procesamiento o simplificar el modelo de aprendizaje . Por último, el informe de pandas_profile ayuda a identificar las columnas innecesarias o que entorpezcan el entrenamiento.
# nombres de las columans del archivo "IBM-Employee-Attrition.cvs"
sourceColumnNames = list(sourceData)
# como existe algo raro en la columna de edad con tag "Age", se renombra a "Age" para facilidad de procesamiento.
sourceData.dtypes
# chequeo los datos
profile.ProfileReport(sourceData)
Despues del analisis de datos, se toman las siguientes decisiones:
# sacando del conjunto de datos las columnas que estan muy correlacionadas o entorpecen el entrenamiento
rejectedColumns = [
"EmployeeCount",
"EmployeeNumber",
"MonthlyIncome",
"Over18",
"StandardHours",
]
# como existe algo raro en la columna de edad con tag "Age", se renombra a "Age" para facilidad de procesamiento.
# nombres viejos de las columnas
oldColumnNames = [
"Age",
]
# nombres nuevos de las columnas por facilidad
newColumnNames = [
"Age",
]
# se asegura no intentar borrar o modificar una columna que ya se borro o se modifico en el XSLX y el CSV.
# se inicia con el archivo CSV
sourceColumnNames = list(sourceData)
for column in rejectedColumns:
if column in sourceColumnNames:
# se elimina la columna que todavia no se ha borrado
sourceData = sourceData.drop(columns = column, axis = 1)
sourceColumnNames = list(sourceData)
# diccionario para renombrar columnas en pandas
renameDictCol = dict()
# renombrar columnas en el CSV para facilidad
for old, new in zip(oldColumnNames, newColumnNames):
if old in sourceColumnNames:
renameDictCol[old] = new
sourceData = sourceData.rename(columns = renameDictCol)
sourceColumnNames = list(sourceData)
# chequeando como va el CSV
sourceData.dtypes
se detectan diferentes columnas con tipo de datos “object”, es necesario cambiar el tipo de dato a “int64”. Para ello se crea un diccionario de transformación creado con los valores únicos presentes en cada columna con su equivalente en números enteros.
No se hace necesario una discretización de las variables porque el comportamiento del fenómeno se abstraería mucho y podría perderse precisión.
# utilizando datypes saco cuales son las columnas con objetos para transformarlas a numeros
objectColumnList = list()
for cname, dtype in zip(list(sourceData), sourceData.dtypes):
if dtype == "object":
# print(cname, dtype)
objectColumnList.append(cname)
# sourceData[cname].astype("int64")
print("--- Lista de Columnas para Cambio de Tipo ---")
print(objectColumnList)
# creando el diccionario de transformacion de las columnas categoricas en numricas
transformationDict = dict()
for cname in objectColumnList:
if len(objectColumnList) != len(transformationDict.keys()):
tempCategory = list(sourceData[cname].unique())
tempCategory.sort()
tempNumeric = list(np.linspace(0, len(tempCategory)-1, len(tempCategory), dtype = "int64"))
for numeric, category in zip(tempNumeric, tempCategory):
# print(numeric, category)
transformationDict[cname] = dict()
for numeric, category in zip(tempNumeric, tempCategory):
# print(numeric, category)
transformationDict[cname][category] = numeric
print("--- Diccionario de Equivalencias para las Columnas ---")
for key in transformationDict:
print(str(key) + ": \n" + str(transformationDict[key]))
# cambiando los valores de las categoricas a numericas para proceder con el dummy
for cname in objectColumnList:
newDataColumn = list()
# si esta en el diccionario y es de tipo objeto se cambia el tipo de dato
if cname in transformationDict.keys() and sourceData[cname].dtype == "object":
# print(cname)
for i in range (0, len(sourceData[cname])):
# print(sourceData[cname][i])
# print(transformationDict[cname][sourceData[cname][i]])
newDataColumn.append(transformationDict[cname][sourceData[cname][i]])
sourceData[cname] = newDataColumn
sourceData.astype({cname:"int64"})
# recisando el cambio de typos en las columnas
sourceData.dtypes
sourceData.head()
En esta etapa represento alternativamente los datos para facilitar el entrenamiento del modelo y retiro los datos que están fuera del rango y no representan el fenómeno.
# fijando las columnas que transformare en dummies
dummyColumnNames = sourceColumnNames
if "Attrition" in dummyColumnNames:
dummyColumnNames.pop(dummyColumnNames.index("Attrition"))
dummyColumnNames
# creando la matriz de datos con dummies
sourceDataDummies = pd.get_dummies(sourceData, columns = dummyColumnNames)
sourceDataDummies.dtypes
#confirmando operaciones
sourceDataDummies.head()
La clase de atrición en la columna “Attrition” esta desbalanceada y para minimizar errores se utiliza el método de balanceo del entrenamiento de datos por SMOTE para aumentar la población de las diferentes clases (en este caso “Yes:1” o “No:0”) y mejorar el rendimiento del modelo de predicción.
Para completar esta tarea se utiliza una clase especializada, y no se debe olvidar el comando "pip install imbalanced-learn" para utilizar SMOTE en el conjunto de datos.
# chequeando los tipos de severidad para ver si necesita balanceo
sourceDataDummies["Attrition"].value_counts()
# conjunto de entrenamiento sin balancear
trainData = sourceDataDummies
trainDataBalance = pd.DataFrame()
# conjunto de entrenamiento entrenado
# las clases 1 y 2 necesitan un sobre muestreo del mismo orden de magnitud que la clase 3
# con SMOTE porque es cool y 42 la respuesta universal de HHGttG
smoteResampler = SMOTE(random_state = 42)
dummyColumnNames = list(sourceDataDummies.drop(columns = ["Attrition"], axis = 1))
smX, smY = smoteResampler.fit_resample(sourceDataDummies.drop(columns = ["Attrition"], axis = 1), sourceDataDummies["Attrition"])
# se crea el nuevo conjunto de datos balanceado de entrenamiento
if "Attrition" not in dummyColumnNames:
trainDataBalance = pd.DataFrame(data = smX, columns = dummyColumnNames)
# se agrega la columna a predecir "Accident_Severity" para no reescribir mas codigo posteriormente
trainDataBalance["Attrition"] = smY
dummyColumnNames = list(sourceDataDummies)
trainDataBalance.dtypes
# se confirma si el balanceo de clases esta bien hecho
trainDataBalance["Attrition"].value_counts()
En esta etapa se crean 2 modelos para entrenar. El primero se denomina ingenuo porque no tiene en cuenta el desbalanceo de clase. Y el segundo, se denomina balanceo por datos porque implementa el procedimiento SMOTE para el conjunto de datos.
Los modelos se denominan:
Estos modelos entrenados se toman como base para la optimización que posteriormente se realiza con los hiper-parámetros.
Específicamente las instrucciones para implementar estas variantes son:
# se remueve la clase a predecir
X = trainData.drop(columns = ["Attrition"], axis = 1)
XB = trainDataBalance.drop(columns = ["Attrition"], axis = 1)
# clase a predecir
y = trainData["Attrition"]
yB = trainDataBalance["Attrition"]
# division de poblacion de entrenamiento y pruebas
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state = 42)
X_trainB, X_testB, y_trainB, y_testB = train_test_split(XB, yB, test_size = 0.2, random_state = 42)
classifierNaive = RandomForestClassifier(n_estimators = 100, max_depth = 2, random_state = 42)
classifierBData = RandomForestClassifier(n_estimators = 100, max_depth = 2, random_state = 42)
# entrenamiento de los modelo clasificador
# classifierNaive = modelo de arbol sin balanceo en datos
classifierNaive.fit(X_train, y_train)
# classifierBData = modelo de arbol con balanceo por conjunto de datos
classifierBData.fit(X_trainB, y_trainB)
# pruebas preliminares del entrenamiento ingenuo
attritionPrediction = classifierNaive.predict(X_test)
# pruebas preliminares del entrenamiento con balanceo por datos
attritionPredictionBData = classifierBData.predict(X_testB)
# validacion preliminar ingenua
naiveScore = cross_val_score(classifierNaive, X_train, y_train, cv = 3)
# validacion preliminar con balanceo por SMOTE
scoreBData = cross_val_score(classifierBData, X_trainB, y_trainB, cv = 3)
# Informe de los resultados para las pruebas ingenuas
print("----- Reporte de Pruebas Ingenuo -----")
print("--- Conteo ---\n" + str(Counter(attritionPrediction)))
print("--- Matriz de Confusion ---\n" + str(confusion_matrix(y_test, attritionPrediction)))
print("--- Reporte de Pruebas: ---")
print(classification_report(y_test, attritionPrediction))
print("--- Puntaje ---\n" + str(naiveScore))
print("--- Puntaje Promedio ---\n" + str(naiveScore.mean()))
preScore = naiveScore.mean()
# Informe de los resultados para las pruebas balanceadas por los datos con SMOTE
print("----- Reporte de Pruebas Balanceado con SMOTE -----")
print("--- Conteo ---\n" + str(Counter(attritionPredictionBData)))
print("--- Matriz de Confusion ---\n" + str(confusion_matrix(y_testB, attritionPredictionBData)))
print("--- Reporte de Pruebas: ---")
print(classification_report(y_testB, attritionPredictionBData))
print("--- Puntaje ---\n" + str(scoreBData))
print("--- Puntaje Promedio ---\n" + str(scoreBData.mean()))
preScoreBData = scoreBData.mean()
En esta sección se define un espacio de optimización del modelo por medio de sus hiper-parámetros (n_estimators, max_features, max_depth, criterion). Además, se define un puntaje o método de evaluación para los modelos, aunque inicialmente se consideraron 2 alternativas del modelo de evaluación, después de experimentos preliminares se escoge el que promedia vía average = "weighted" porque no se observa un cambio en su comportamiento con el parámetro average = "macro".
Importante para acelerar el proceso de optimización:
# hyperparametros a optimizar
estimators = [100, 200, 400]
features = ["auto", "sqrt", "log2"]
depths = [2, 3, 4, 5, 6]
criterions = ["gini"] #, "entropy"]
# score sin tener en cuenta los pesos
pScoreMacro = make_scorer(precision_score, average = "macro")
#score teniendo en cuenta los pesos
pScoreWeight = make_scorer(precision_score, average = "weighted")
# criterios de evaluacion por los que se quiere optimizar el modelo
scores = [pScoreMacro, pScoreWeight]
hyperParameters = {
'n_estimators': estimators,
'max_features': features,
'max_depth' : depths,
'criterion' :criterions
}
# funcion que implementa el ajuste de los hyperparametros con el estimador que no tiene en cuenta el desbalanceo
searchGridResults = GridSearchCV(estimator = classifierNaive,
scoring = scores[1],
cv = 3,
param_grid = hyperParameters, n_jobs = -2, verbose = 5)
# ajusta el modelo optimizando los hyperparametros
searchGridResults.fit(X_train, y_train)
# mejor resultado segun presicion
bestScore = searchGridResults.best_score_
# mejores hyperpametros segun presicion
bestParameters = searchGridResults.best_params_
# funcion que implementa el ajuste de los hyperparametros con el estimador teniendo en cuenta peso
searchGridResultsBData = GridSearchCV(estimator = classifierBData,
scoring = scores[1],
cv = 3,
param_grid = hyperParameters, n_jobs = -2, verbose = 5)
# ajusta el modelo optimizando los hyperparametros
searchGridResultsBData.fit(X_trainB, y_trainB)
# mejor resultado segun presicion
bestScoreBData = searchGridResultsBData.best_score_
# mejores hyperpametros segun presicion
bestParametersBData = searchGridResultsBData.best_params_
En esta sección se crea la matriz de confusión y el informe del resto de variables de rendimiento de los modelos probados par su futuro análisis.
# pruebas preliminares del entrenamiento ingenuo
attritionPrediction = searchGridResults.predict(X_test)
# pruebas preliminares del entrenamiento con balanceo por datos
attritionPredictionBData = searchGridResultsBData.predict(X_testB)
# Informe de los resultados para las pruebas ingenuas optimizadas
print("----- Reporte de Pruebas Ingenuo -----")
print("Parametros Optimizados: " + str(bestParameters))
print("--- Conteo de Clasificaciones ---\n" + str(Counter(attritionPrediction)))
print("--- Matriz de Confusion ---\n" + str(confusion_matrix(y_test, attritionPrediction)))
print("--- Reporte de Pruebas: ---")
print(classification_report(y_test, attritionPrediction))
print("--- Mejor Puntaje Promedio ---\n" + str(searchGridResults.best_score_))
# Informe de los resultados optimizadas para las pruebas balanceadas por los datos con SMOTE
print("----- Reporte de Pruebas Balanceado con SMOTE -----")
print("Parametros Optimizados: " + str(bestParametersBData))
print("--- Conteo de Clasificaciones ---\n" + str(Counter(attritionPredictionBData)))
print("--- Matriz de Confusion ---\n" + str(confusion_matrix(y_testB, attritionPredictionBData)))
print("--- Reporte de Pruebas: ---")
print(classification_report(y_testB, attritionPredictionBData))
print("--- Mejor Puntaje Promedio ---\n" + str(searchGridResultsBData.best_score_))
Durante la preparación de datos, se simplifica el conjunto de entrenamiento transformando los datos categóricos de tipo String a números enteros y se crea una representación alternativa del conjunto de datos para agilizar el entrenamiento del árbol. No se discretizan las variables como edad, tiempo en la empresa y similares porque son comportamientos importantes del fenómeno y se espera predecir el retiro de los empleados con por lo menos una precisión anual.
Después en el entrenamiento preliminar de los dos modelos (ingenuo y SMOTE) se observa que el entrenamiento del árbol ingenuo no permite reconocer la clase minoritaria, en este caso en la columna “Attrition” está el sí (“Yes:0”) que significa que el empleado si se retiró de la empresa.
Por lo anterior, se ejecuta el procedimiento SMOTE para balancear el conteo dentro del conjunto de datos de entrenamiento. Como consecuencia el modelo entrenado con SMOTE tiene un mejor rendimiento y puede reconocer de manera adecuada las dos clases de interés de la atrición laboral.
Adicionalmente, durante la optimización se decidió una búsqueda ingenua por el método “GridSearchCV” y teniendo en cuenta los parámetros de numero de estimadores, características máximas para tener en cuenta, profundidad del árbol y criterio de evaluación (“n_estimators”, “max_features”, “max_depth”, “criterion” respectivamente).
En un principio el criterio de evaluación tenia dos alternativas, “gini” y “entropy” pero como el criterio de entropía se considera generalmente de menor calidad se decide utilizar solo el “gini”. Por otro lado, el tiempo de optimización del modelo es menor a 5 minutos, lo que confirma lo innecesario que es discretizar algunas variables continuas de entrenamiento.
Por último, en la optimización los resultados del árbol con SMOTE son mejores en puntaje y capacidad de distinguir las diferentes categorías (“Yes:1” y “No:0”). De nuevo en la optimización el árbol con un modelo ingenuo es incapaz de reconocer la clase minoritaria, en este caso “Yes:0”.